LK

Replicated Squad-System

In Development
GitHub

This system is designed to define leader-member relationships between objects, where a leader directs its members’ behavior through commands.

There are two conditions that must be met when assigning these relationships. A leader can be any actor and does not require additional functionality, since the interface’s functionality can be accessed publicly.

A class representing a member must inherit the ICommandable interface to receive commands from its leader via the member wrapper class.

The three core interfaces are defined as follows:

// Leader Interface
UINTERFACE(MinimalAPI)
class ULeaderInterface : public UInterface
{
	GENERATED_BODY()
};

/**
 * 
 */
class SUPERDEFENCE_API ILeaderInterface
{
	GENERATED_BODY()
public:
	virtual void ExecuteCommand(USquadCommand* Command, const FSquadCommandPayload& SquadCommandPayload) = 0;
	virtual void NotifySquadReady() = 0;
	virtual void AddMember(IMemberInterface* NewMember) = 0;
	virtual void RemoveMember(IMemberInterface* MemberToRemove) = 0;
	virtual TArray<IMemberInterface*> GetMembers() = 0;
	virtual void Join(ILeaderInterface* Other) = 0;
	virtual void Split() = 0;
	virtual void TerminateSquad() = 0;
};
// Member Interface
  UINTERFACE(MinimalAPI)
  class UMemberInterface : public UInterface
  {
    GENERATED_BODY()
  };

  /**
   * 
   */
  class SUPERDEFENCE_API IMemberInterface
  {
    GENERATED_BODY()
  public:
    virtual void ExecuteCommand(USquadCommand* Command, const FSquadCommandPayload& SquadCommandPayload) = 0;
    virtual void Init(ICommandableInterface* Outer, ILeaderInterface* InLeader) = 0;
    virtual void SetMemberID(const int32 InMemberID) = 0;
    virtual int32 GetMemberID() const = 0;
    virtual const FSquadMemberPositionInfo* GetPositionInfo() = 0;
    virtual void Die() = 0;
    virtual ILeaderInterface* GetLeader() const = 0;
    virtual void Move(const FComputedLocationInfo& ComputedLocation) const = 0;
    virtual void SetVisibilitySetting(const EVisibilitySetting InVisibilitySetting) = 0;
  };
  
// Commandable Interface
  UINTERFACE()
  class UCommandableInterface : public UInterface
  {
    GENERATED_BODY()
  };
  class ICommandableInterface
  {
    GENERATED_BODY()
  public:
    virtual void GoTo(const FSquadCommandPayload& SquadCommandPayload) = 0;
    virtual void Attack(const FSquadCommandPayload& SquadCommandPayload) = 0;
    virtual void Swarm(const FSquadCommandPayload& SquadCommandPayload) = 0;
    virtual void Flee(const FSquadCommandPayload& SquadCommandPayload) = 0;

    // Possible commands for the future:
    virtual void FollowPath() = 0;
    virtual void JumpDown() = 0;
    
    virtual void SetMemberWrapper(USquadMember* InMemberWrapper) = 0;
    virtual void InitStats() = 0;
    virtual const FSquadMemberPositionInfo* GetPositionInfo(const int32& InSquadID) = 0;
    virtual void Move(const FComputedLocationInfo& ComputedLocation) = 0;
    virtual void SetVisibilitySetting(const EVisibilitySetting InVisibility) = 0;
  };
  

The leader and member wrappers are implemented as minimal UObjects, keeping them extremely lightweight. They handle the core functionality of the system and define the relationship between leader and members.

/**
 * Wrapper class for SquadLeaders, extending any Actor with additional functionality
 * Provides centralized logic and control flow for all bound members
 */
UCLASS(Blueprintable, BlueprintType)
class SUPERDEFENCE_API USquadLeader : public UObject, public ILeaderInterface, public FTickableGameObject
{
	GENERATED_BODY()
public:
	USquadLeader();
	virtual void BeginDestroy() override;
	virtual void Init(AActor* Outer);
	
	/* Start Leader Interface */
	virtual void AddMember(IMemberInterface* NewMember) override;
	virtual void RemoveMember(IMemberInterface* MemberToRemove) override;
	virtual TArray<IMemberInterface*> GetMembers() override;
	virtual void Join(ILeaderInterface* Other) override;
	virtual void Split() override;
	UFUNCTION(BlueprintCallable, Category = "Squad")
	virtual void ExecuteCommand(USquadCommand* Command, const FSquadCommandPayload& SquadCommandPayload) override;

	UFUNCTION(BlueprintCallable, Category = "Squad")
	virtual void NotifySquadReady() override;

	UFUNCTION(BlueprintCallable, Category = "Squad")
	virtual void Blueprint_AddMember(UObject* NewMember);
	virtual void TerminateSquad() override;
	/* End Leader Interface */

	/* Start TickableGameObject Interface */
	virtual void Tick(float DeltaTime) override;
	virtual TStatId GetStatId() const override;
	virtual bool IsTickable() const override { return true; }
	/* Start TickableGameObject Interface */
	
	UObject* Get() const { return Leader; }

private:
	UPROPERTY()
	AActor* Leader = nullptr;
	TMap<int32, IMemberInterface*> MembersBySquadID;
	int32 GetNewSquadID();
	int32 NextSquadID = 1;
	UPROPERTY()
	USquadCommand* CurrentCommand = nullptr;

	FMovementHelper MovementHelper;
	
	/// Squad Movement Handling ///
	virtual void StartSquadMovement();
	void TickSquad(float DeltaTime);
	bool bMoveSquad = false;
	/// Squad Movement Handling ///

	float CurrentTickInterval = 0.016f;
	float TimeSinceLastTick = 0.0f;
	void EnableRangeCheckTimer(const bool bEnable);
	void DoRangeCheck();
	void UpdateTickInterval(float DistanceToClosestPlayer);
	void UpdateSquadVisibilitySetting(const EVisibilitySetting InVisibilitySetting);
	FTimerHandle LeaderTimer;

	static const USquadEnemySettings* GetSettings();
};
/**
 * Wrapper class for SquadMembers, extending any Commandable Object
 * with additional functionality when it becomes part of a squad
 */
UCLASS(BlueprintType, Blueprintable)
class SUPERDEFENCE_API USquadMember : public UObject, public IMemberInterface
{
	GENERATED_BODY()
public:
	USquadMember();
	virtual void BeginDestroy() override;
	
	virtual void ExecuteCommand(USquadCommand* Command, const FSquadCommandPayload& SquadCommandPayload) override;
	virtual void Init(ICommandableInterface* Outer, ILeaderInterface* InLeader) override;
	virtual void SetMemberID(int32 const InMemberID) override { SquadMemberID = InMemberID; };
	virtual int32 GetMemberID() const override { return SquadMemberID; };
	virtual const FSquadMemberPositionInfo* GetPositionInfo() override;
	virtual void Die() override;
	virtual void Move(const FComputedLocationInfo& ComputedLocation) const override;
	virtual void SetVisibilitySetting(const EVisibilitySetting InVisibilitySetting) override;
	
	ICommandableInterface* Get() const { return Member; }
	virtual ILeaderInterface* GetLeader() const override { return Leader; }
	
private:
  // TODO: change both to TScriptInterface<IInterface>(ObjectPtr) in the future!;
	ICommandableInterface* Member = nullptr;
	ILeaderInterface* Leader = nullptr;

	int32 SquadMemberID = -1;
};

Entities are wrapped using a BlueprintFunctionLibrary that serves as a Factory:

// Factory method for creating SquadLeader UObjects
USquadLeader* USquadFactory::CreateSquadLeader(AActor* Outer)
{
	USquadLeader* SquadLeader = NewObject<USquadLeader>(Outer);
	SquadLeader->Init(Outer);
	return SquadLeader;
}

// Factory method for creating SquadMember UObjects
USquadMember* USquadFactory::CreateSquadMember(UObject* CommandableObject, USquadLeader* Leader)
{
	if (!CommandableObject || !CommandableObject->Implements<UCommandableInterface>())
	{
		UE_LOG(LogTemp, Warning, TEXT("CreateSquadMember: Object does not implement ICommandableInterface"));
		return nullptr;
	}

	ICommandableInterface* Commandable = Cast<ICommandableInterface>(CommandableObject);
	if (!Commandable)
		return nullptr;

	USquadMember* NewMember = NewObject<USquadMember>(CommandableObject);
	NewMember->Init(Commandable, Leader);
	Commandable->SetMemberWrapper(NewMember);
	return NewMember;
}

The Leader defines behavior that is synchronized across its members. This is achieved through the command structure, where each command is identified by name and has its behavior implemented in the Execute function.

/**
 * Abstract base class for commands, containing the command’s name and the Execute function
 * Child classes can define their own name and implement the corresponding behavior inside Execute
 */
UCLASS(Abstract)
class SUPERDEFENCE_API USquadCommand : public UObject, public ISquadCommandInterface
{
	GENERATED_BODY()
public:
	virtual void Execute(ICommandableInterface* Commandable, const FSquadCommandPayload& SquadCommandPayload) override;

	virtual FName GetCommandName() const { return NAME_None; }
};
// Command SquadMembers to go to a specific location
UCLASS()
class UGoToCommand : public USquadCommand
{
	GENERATED_BODY()
public:
	virtual void Execute(ICommandableInterface* Commandable, const FSquadCommandPayload& SquadCommandPayload) override { Commandable->GoTo(SquadCommandPayload); }
	virtual FName GetCommandName() const override { return "GoTo"; }
};

// Command SquadMembers to attack a specified target
UCLASS()
class UAttackCommand : public USquadCommand
{
	GENERATED_BODY()
public:
	virtual void Execute(ICommandableInterface* Commandable, const FSquadCommandPayload& SquadCommandPayload) override { Commandable->Attack(SquadCommandPayload); }
	virtual FName GetCommandName() const override { return "Attack"; }
};

Since commands are extremely lightweight and hold no data, we create them once and recycle them inside our Factory:

// Static map containing all commands
TMap<FName, USquadCommand*> USquadFactory::CommandMap;

// This must be called once on startup, either by a Subsystem or a LevelInstance
void USquadFactory::RegisterCommands()
{
	CommandMap.Add("GoTo", NewObject<UGoToCommand>(GetTransientPackage()));
	CommandMap.Add("Attack", NewObject<UAttackCommand>(GetTransientPackage()));
	CommandMap.Add("Swarm",  NewObject<USwarmCommand>(GetTransientPackage()));
	CommandMap.Add("Flee",  NewObject<UFleeCommand>(GetTransientPackage()));
	CommandMap.Add("Chase", NewObject<UChaseCommand>(GetTransientPackage()));
	CommandMap.Add("Retreat", NewObject<URetreatCommand>(GetTransientPackage()));
}

// How to globally receive a Command
USquadCommand* USquadFactory::GetCommand(UObject* WorldContextObject, const FName CommandName)
{
	if (USquadCommand** Command = CommandMap.Find(CommandName))
		return *Command;

	UE_LOG(LogTemp, Error, TEXT("Unknown CommandName: %s"), *CommandName.ToString());
	return nullptr;
}

// This must be called once for cleanup after ending the game,
// either by a Subsystem or a LevelInstance
void USquadFactory::ClearCommandMap()
{
	CommandMap.Empty();
}

The purpose of this system is to allow a single leader class to command its members to perform specific actions. The implementation of these actions is fully dependent on each member, enabling vastly different behaviors across individual squad members.

An example could be a squad composed of common enemies and an elite unit. The common enemies might respond very differently than the elite counterpart - for instance, the elite could leave the squad, initialize its own behavior tree, and engage the player directly, while the rest continue taunting the player or move along.

Squad movement is managed independently by the squad leader, since not all squads require movement in the first place. The FMovementHelper class utilizes each squad member’s individual movement data to compute their next location for the upcoming frame, and then forwards this result back to the respective member. To reduce the workload on the game thread, these calculations are executed in parallel using multithreading.

/*
* Function handling movement for a single squad
* Computes each member’s next location on separate threads
* Should only be used for performance-heavy calculations, since the threading overhead can otherwise outweigh the benefits
*/
void FMovementHelper::MoveSquad(const float DeltaTime)
{
	TArray<int32> MemberIDs;
	MembersBySquadID.GetKeys(MemberIDs);

    // Take a snapshot of all relevant data, ensuring consistency across frames
    // This avoids manipulating data that may no longer exist in the next tick
	const TMap<int32, const FSquadMemberPositionInfo*>& MemberSnapShot = MembersBySquadID;
	const TMap<int32, FPathPointData>& MemberPathSnapShot = MemberPaths;
	const TArray<FPathPointData>& PathRef = CurrentPathPoints;

	TArray<FComputedLocationInfo> NewLocations;

	ParallelFor(MemberIDs.Num(), [this, &MemberIDs, &MemberSnapShot, &MemberPathSnapShot, &PathRef, DeltaTime](const int32 Index)
	{
		TRACE_CPUPROFILER_EVENT_SCOPE(MoveSquad_WorkItem);
		const int32 ThreadID = FPlatformTLS::GetCurrentThreadId();
		TRACE_BOOKMARK(TEXT("Running on Thread %d (Index %d)"), ThreadID, Index);
		
		int32 MemberID = MemberIDs[Index];
		const FSquadMemberPositionInfo* Info = MemberSnapShot[MemberID];

		if (!Info)
			return;

		const FPathPointData& TargetLocation = MemberPathSnapShot[MemberID];

        // Check if the current member requires movement. If so, enqueue the computed update into the movement queue
		if (FComputedLocationInfo ComputedLocation = ComputeLocationForMember(MemberID, *Info, TargetLocation, PathRef, DeltaTime); ComputedLocation.HasMoved())
		{
			MemberPositionUpdateQueue.Enqueue({MemberID, ComputedLocation});
		}
	}, EParallelForFlags::Unbalanced);
}

Since all modifications to active actor instances must occur on the game thread, we use a thread-safe TQueue to collect the newly computed locations. These results are then dequeued and applied each frame on the game thread.

/*
* Dequeues all computed locations and applies them to the corresponding squad members
* Guaranteed thread safety via the use of TQueue
*/
void FMovementHelper::FlushMovementQueue()
{
	const FVector& FinalTarget = CurrentPathPoints.Last().PointLocation;
	TPair<int32, FComputedLocationInfo> Pair;

	TRACE_CPUPROFILER_EVENT_SCOPE(MoveSquad_FlushCommands);
	TRACE_BOOKMARK(TEXT("Flushing movement queue"));
	
	while (MemberPositionUpdateQueue.Dequeue(Pair))
	{
		if (!Pair.Value.HasMoved())
		{
			if (FVector::DistSquared(Pair.Value.Location, FinalTarget) < FMath::Square(10.f))
			{
				MembersBySquadID.Remove(Pair.Key);
				FinishedMembers.Add(Pair.Key);
			}
			continue;
		}

		if (IMemberInterface* const* Member = SquadMembers.Find(Pair.Key))
		{
			(*Member)->Move(Pair.Value);
		}
	}
	
    // Placeholder implementation for leader movement
	MoveLeader();
}

This system is still in active development, so not all features are available yet and are subject to change.